//@author A0087191Y
package net.mysoc.w111j.io;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedList;
import java.util.List;

import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.converters.Converter;
import com.thoughtworks.xstream.converters.MarshallingContext;
import com.thoughtworks.xstream.converters.UnmarshallingContext;
import com.thoughtworks.xstream.converters.basic.AbstractSingleValueConverter;
import com.thoughtworks.xstream.io.HierarchicalStreamReader;
import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
import com.thoughtworks.xstream.io.xml.DomDriver;
import net.mysoc.w111j.model.Category;
import net.mysoc.w111j.model.Expense;
import net.mysoc.w111j.model.Money;
import net.mysoc.w111j.model.PaymentType;
import net.mysoc.w111j.model.User;

/**
 * Class handling the serialization and writing of User objects to an XML file, and the deserialization and reading of
 * XML files to User objects.
 */
public class UserXMLHelper {
    /** The XStream object used by all public methods. */
    private static final XStream XS = createXStream();

    private static XStream createXStream() {
        XStream xs = new XStream(new DomDriver());
        xs.alias("user", User.class);
        xs.alias("expense", Expense.class);
        xs.alias("category", Category.class);
        xs.alias("paymentType", PaymentType.class);
        xs.registerConverter(new UserConverter());
        xs.registerConverter(new MoneyConverter());
        return xs;
    }

    /**
     * Takes in a User object, serializes it into XML using the XStream library, and writes it to path.
     *
     * @param path the file path to save serialized User XML to.
     * @param user the user to save as XML.
     * @return {@code true} if save was successful, otherwise {@code false}.
     */
    public static boolean saveUser(String path, User user) throws IOException {
        String xml = XS.toXML(user);
        writeToFile(path, xml);
        return true;
    }

    /**
     * Loads and returns an instantiated User object from the XML file located at path.
     *
     * @param path file path to load user XML from.
     * @return the initialized User object from the XML file located at path.
     */
    public static User loadUser(String path) throws IOException {
        String xml = readFromFile(path);
        User user = (User) XS.fromXML(xml);
        return user;
    }

    /**
     * Writes a given String to a file at a given path. File access mode is w. (w: Write-only, truncates existing file
     * to zero length or creates a new file for writing.)
     *
     * @param path file path to write data to.
     * @param data a String to write to file.
     */
    private static void writeToFile(String path, String data) throws IOException {
        try {
            Path target = Paths.get(path);

            BufferedWriter writer = Files.newBufferedWriter(target, Charset.defaultCharset());
            writer.append(data);
            writer.flush();
        } catch (IOException e) {
            e.printStackTrace();
            throw e;
        }
    }

    /**
     * Reads from a file and returns a {@code String} containing the file's contents.
     *
     * @param path file path to read XML from.
     * @return a {@code String} with the file's contents.
     */
    private static String readFromFile(String path) throws IOException {
        StringBuilder builder = new StringBuilder();
        Path target = Paths.get(path);
        try {
            BufferedReader reader = Files.newBufferedReader(target, Charset.defaultCharset());
            String line;

            while ((line = reader.readLine()) != null) {
                builder.append(line);
            }
        } catch (IOException e) {
            if (!Files.exists(target)) {
                throw new FileNotFoundException("Cannot read from file " + path + ": file does not exist");
            } else {
                e.printStackTrace();
                throw e;
            }
        }

        return builder.toString();
    }

    /** A converter for {@code User} objects. */
    private static class UserConverter implements Converter {
        @Override
        public boolean canConvert(Class type) {
            return type.equals(User.class);
        }

        @Override
        public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) {
            User user = (User) source;
            writer.addAttribute("next-category-id", Integer.toString(user.getNextCategoryId()));
            writer.addAttribute("next-expense-id", Integer.toString(user.getNextExpenseId()));
            writer.addAttribute("next-paymentType-id", Integer.toString(user.getNextPaymentTypeId()));
            writer.startNode("budget");
            context.convertAnother(user.getBudget());
            writer.endNode();
            writer.startNode("categories");
            for (Category c : user.getCategories()) {
                writer.startNode("category");
                context.convertAnother(c);
                writer.endNode();
            }
            writer.endNode();
            writer.startNode("paymentTypes");
            for (PaymentType pt : user.getPaymentTypes()) {
                writer.startNode("paymentType");
                context.convertAnother(pt);
                writer.endNode();
            }
            writer.endNode();
            writer.startNode("expenses");
            for (Expense e : user.getExpenses()) {
                writer.startNode("expense");
                context.convertAnother(e);
                writer.endNode();
            }
            writer.endNode();

        }

        @Override
        public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) {
            Money budget = new Money();
            int nextCategoryId = Integer.parseInt(reader.getAttribute("next-category-id"));
            int nextExpenseId = Integer.parseInt(reader.getAttribute("next-expense-id"));
            int nextPaymentTypeId = Integer.parseInt(reader.getAttribute("next-paymentType-id"));
            List<Category> categories = new LinkedList<>();
            List<Expense> expenses = new LinkedList<>();
            List<PaymentType> paymentTypes = new LinkedList<>();

            while (reader.hasMoreChildren()) {
                reader.moveDown();
                switch (reader.getNodeName()) {
                    case "budget":
                        budget = (Money) context.convertAnother(null, Money.class);
                        break;
                    case "categories":
                        while (reader.hasMoreChildren()) {
                            reader.moveDown();
                            Category category = (Category) context.convertAnother(null, Category.class);
                            categories.add(category);
                            reader.moveUp();
                        }
                        break;
                    case "paymentTypes":
                        while (reader.hasMoreChildren()) {
                            reader.moveDown();
                            PaymentType paymentType = (PaymentType) context.convertAnother(null, PaymentType.class);
                            paymentTypes.add(paymentType);
                            reader.moveUp();
                        }
                        break;
                    case "expenses":
                        while (reader.hasMoreChildren()) {
                            reader.moveDown();
                            Expense expense = (Expense) context.convertAnother(null, Expense.class);
                            expenses.add(expense);
                            reader.moveUp();
                        }
                        break;
                }
                reader.moveUp();
            }

            return new User(budget, nextCategoryId, nextExpenseId, nextPaymentTypeId,
                    categories, paymentTypes, expenses);
        }
    }

    /** A converter for {@code Money} objects. */
    private static class MoneyConverter extends AbstractSingleValueConverter {
        @Override
        public boolean canConvert(Class type) {
            return type.equals(Money.class);
        }

        @Override
        public Object fromString(String str) {
            return new Money(str);
        }
    }
}
